// Copyright 2008 Tony Gentilcore
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//     http://www.apache.org/licenses/LICENSE-2.0
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.


// TODO:
// 1 remove suites
// 2 save suite as

/**
 * Creates a new Benchmarking suite.
 *
 * @param {string} name A user friendly name for this suite.
 * @param {number} iterations Default number of iterations for benchmarks.
 * @param {Object} All remaining params are functions to be added to the suite.
 * @param {boolean} skipSave Whether to skip saving this suite.
 * 
 * @constructor
 */
function BenchmarkSuite(name, iterations, params, skipSave) {
  window.benchmarksuite = this;
  this.benchmarks = [];
  this.otherruns = [];
  this.report_runs = document.forms[0].cbBrowsers.checked;
  this.normalize = document.forms[0].cbNormalize.checked;
  this.name = name;
  this.iterations = iterations;
  this.setParams(params);
  this.control = new Benchmark("function control(){}");
  this.controlTime = 0;
  this.nextToRun = undefined;

  // Check the 5th argument to see if its an array or fn.
  if (arguments[4]) {
    if (arguments[4].prototype) {
      for (var i = arguments.length - 1; i > 3; --i) {
	this.benchmarks[i - 4] = new Benchmark(arguments[i]);
      }
    } else {
      for (var i = 0, len = arguments[4].length; i < len; ++i) {
	this.benchmarks[i] = new Benchmark(arguments[4][i]);
      }
    }
  }

  this.display();

  if (!skipSave) {
    this.saveSuite();
  }
}

/**
 * Sets the parameters that will be passed to each benchmark.
 */
BenchmarkSuite.prototype.setParams = function(params) {
  var lastParams = this.params || [];
  if (typeof(params) == 'string') {
    this.params = window.eval('(' + params + ')');
  } else {
    this.params = params;
  }

  // If it wasn't an array, display an error and revert.
  this.serializedParams = JSON.stringify(this.params);
  if (!this.params.join) {
    this.params = lastParams;
    alert('Params must be an Array of arguments to pass to each benchmark.');
    return this.serializedParams;
  }

  this.paramsId = djbHash(this.serializedParams);

  // Clear all previous runs
  for (var i = this.benchmarks.length - 1; i >= 0; --i) {
    this.benchmarks[i].time = 0;
    this.benchmarks[i].runs = 0;
  }

  return this.serializedParams;
};

BenchmarkSuite.prototype.saveSuite = function() {
  if (!window.isSignedIn) return;

  var bm_ids = [];
  var bm_names = [];
  var bm_bodies = [];
  for (var i = 0, len = this.benchmarks.length; i < len; ++i) {
    bm_ids.push(this.paramsId + this.benchmarks[i].id);
    bm_names.push(encodeURIComponent(this.benchmarks[i].name));
    bm_bodies.push(encodeURIComponent(
        JSON.stringify(this.benchmarks[i].body).replace(/\\r/g, '')));
  }
  var params = ['name=', encodeURIComponent(this.name),
		'&iters=', encodeURIComponent(this.iterations),
		'&params=', encodeURIComponent(this.serializedParams),
		'&bm_id=', bm_ids.join('&bm_id='),
		'&bm_name=', bm_names.join('&bm_name='),
		'&bm_body=', bm_bodies.join('&bm_body=')];
  if (this.id) {
    params.push('&id=' + encodeURIComponent(this.id));
  }
  post('/savesuite', params.join(''),
       function(response) {
         if (response) {
           window.benchmarksuite.id = response;
           window.location.hash = 'suite=' + response;
	   addCurrentSuiteToList();
	 }
       });
};

/**
 * Runs all benchmarks in the suite, yielding for 150ms and forcing IE garbage
 * collection before each run.
 */
BenchmarkSuite.prototype.runAll = function() {
  // Since this method recalls itself, manage the state here. 
  if (isNaN(this.nextToRun)) {
    // Initial state, just queue everything up.
    this.running = true;
    this.nextToRun = this.benchmarks.length;
  } else if (this.nextToRun == this.benchmarks.length) {
    // First callback, run the control which is an empty method.
    // Its time will be subtracted from the other runs to factor out the
    // overhead of the loop and function calling.
    this.controlTime = this.control.run(this.iterations, [], 0);
    --this.nextToRun;
  } else if (this.nextToRun >= 0) {
    // Running a normal benchmark.
    var bench = this.benchmarks[this.nextToRun--];
    if (bench) {
      bench.lastTime = bench.run(this.iterations, this.params,
	                         this.controlTime);
    }
  } else {
    // Final state, reset everything.
    this.running = false;
    this.nextToRun = undefined;
    this.doneRunning();
    return;
  }
  
  // Force GarbageCollection() in IE to help get consistent results.
  window.CollectGarbage && CollectGarbage();

  // If there are more things to do, recall this method.
  var self = this;
  setTimeout((function() {
	      return function() {
                self.runAll();
	      };
	  })(), 150);
};

/**
 * Reports the results of all runs.
 */
BenchmarkSuite.prototype.reportRuns = function() {
  var self = this;

  var bm_ids = [];
  var bm_runs = [];
  var bm_times = [];
  for (var i = 0, len = this.benchmarks.length; i < len; ++i) {
    if (this.benchmarks[i].lastTime > 0) {
      bm_ids.push(this.paramsId + this.benchmarks[i].id);
      bm_runs.push(encodeURIComponent(this.iterations));
      bm_times.push(encodeURIComponent(this.benchmarks[i].lastTime));
    }
  }

  if (bm_ids.length < 1) return;

  var params = ['bm_ids=', bm_ids.join('&bm_ids='),
                '&bm_runs=', bm_runs.join('&bm_runs='),
                '&bm_times=', bm_times.join('&bm_times=')].join('');
  post('/saveruns', params,
       (function(response) {	   
	  if (response) {
	    self.otherruns = window.eval('(' + response + ')');
	    self.displayGraph();
	  }
        }));
};

BenchmarkSuite.prototype.doneRunning = function() {
  if (this.report_runs) {
    this.reportRuns();
  } else {
    this.displayGraph();
  }
  this.displayControls();
};

BenchmarkSuite.prototype.display = function() {
  this.displayGraph();
  this.clearControls();
  this.displayControls();  
};

BenchmarkSuite.prototype.clearControls = function() {
  var suiteDiv = document.getElementById('suiteDiv');
  if (suiteDiv) {
    suiteDiv.parentNode.removeChild(suiteDiv);
  }
};

/**
 * Outputs a run button for each Benchmark as well as a Run All button.
 */
BenchmarkSuite.prototype.displayControls = function() {
  var self = this;
  if (this.name) {
    document.title = 'JsBench - ' + this.name;
    getOrAddElement('headerElem', 'SPAN').innerHTML = 'JsBench - ';
    getOrAddElement('titleElem', 'SPAN').innerHTML = this.name;
  }

  var suiteDiv = getOrAddElement('suiteDiv', 'DIV');
  getOrAddElement('btnRunAll', 'INPUT',
		  {'type': 'button',
		   'onclick': (function() {
				 this.disabled = true;
				 self.runAll();
			       }),
		   'value': 'Run All Benchmarks'}, suiteDiv).disabled =
      !this.benchmarks.length;
  getOrAddElement('btnClearAll', 'INPUT',
		  {'type': 'button',
		   'onclick':
		   (function() {
		      for (var i = self.benchmarks.length - 1; i >= 0; --i) {
			self.benchmarks[i].time = 0;
			self.benchmarks[i].runs = 0;
			self.displayGraph();
		      }
		    }),
		   'value': 'Clear All Runs'}, suiteDiv).disabled = 
      !this.benchmarks.length;

  getOrAddElement('labelIterations', 'LABEL', {'for': 'txtIterations'},
		  suiteDiv).innerHTML = 'Iterations:&nbsp;';
  var txtIterations = getOrAddElement('txtIterations', 'INPUT',
                                      {'type': 'text', 'size': '8'},
				      suiteDiv);
  txtIterations.value = self.iterations;
  txtIterations.onchange = (function() {
                              self.iterations = 0 | this.value;
			      self.saveSuite();
                            });

  getOrAddElement('divClear', 'DIV', {}, suiteDiv);

  getOrAddElement('labelParams', 'LABEL', {'for': 'txtParams'},
		  suiteDiv).innerHTML = 'Function Parameters:&nbsp;';
  var txtParams = getOrAddElement('txtParams', 'INPUT', {'type': 'text'},
				  suiteDiv);
  txtParams.value = self.serializedParams;
  txtParams.onchange = (function() {
	  this.value = self.setParams(this.value);
	  self.saveSuite();
      });


  var newBenchmarkDiv = getOrAddElement('newBenchmarkDiv', 'DIV',
                                        {'class': 'benchmark',
					 'className': 'benchmark',
					 'height': '130px'}, suiteDiv);
  getOrAddElement('newBenchmarkBtn', 'INPUT',
                  {'type': 'button',
		   'value': 'Add New Benchmark'},
		  newBenchmarkDiv).onclick = (function() {
			self.benchmarks[self.benchmarks.length] = 
                            new Benchmark('function empty() {}');
			self.displayControls();
			if (window.isSignedIn) {
			  // TODO only do this if they own the suite.
                          self.saveSuite();
			}
		      });

  var currDiv;
  var currBtn;
  var currTextArea;
  var titleDiv;
  for (var i = 0, len = this.benchmarks.length; i < len; ++i) {
    currDiv = getOrAddElement('benchmarkDiv' + i, 'DIV',
			      {'class': 'benchmark', 'className': 'benchmark'},
			      suiteDiv, newBenchmarkDiv);
    titleDiv = getOrAddElement('titleDiv' + i, 'DIV',
			       {'class': 'title', 'className': 'title'},
			       currDiv);
    getOrAddElement('removeImg' + i, 'IMG',
		    {'src': '/images/close.gif', 'height': '18',
		     'width': '18', 'alt': 'Remove',
		     'class': 'remove', 'className': 'remove'},
		    titleDiv).onclick = (function(index) {
	    return function() {
		self.benchmarks.splice(index, 1);
		self.clearControls();
		self.displayControls();
		self.saveSuite();
		return false;
	    };
	})(i);
    getOrAddElement('titleNameDiv' + i, 'DIV',
                    {'class': 'titleName', 'className': 'titleName'},
		    titleDiv).innerHTML = this.benchmarks[i].name;

    currTextArea = getOrAddElement('benchmarkTxt' + i, 'TEXTAREA', {},
				   currDiv);
    currTextArea.onchange =
      (function(index) {
	 return function() {
           self.benchmarks[index] = new Benchmark(this.value);
	   self.saveSuite();
	 };
       })(i);
    currTextArea.value = this.benchmarks[i].body;

    currBtn = getOrAddElement('benchmarkBtn' + i, 'INPUT',
			      {'type': 'button', 'value': 'Test Return Value'},
			      currDiv).onclick =
      (function(index) {
	 return function() {
           try {
	     alert(self.benchmarks[index].fn.apply(window, self.params));
           } catch (e) {
             alert(self.benchmarks[index].name + ' threw error: ' + e);
           }
	 };
       })(i);
  }
};

/**
 * Parses runs and other runs into a single structure keyed by run.
 * @return {run_name: {bm_id: {name: name, time: time, runs: runs}}}
 */
BenchmarkSuite.prototype.parseRuns = function() {
  var runs = {};
  runs['Current runs'] = {max: 0};
  var bm;
  for (var i = 0, len = this.benchmarks.length; i < len; ++i) {
    bm = this.benchmarks[i];
    runs['Current runs'][bm.id] = {runs: bm.runs, time: bm.time};
    runs['Current runs'].max = Math.max(runs['Current runs'].max, bm.time);
    for (var otherRun in this.otherruns[i]) {
      if (!runs[otherRun]) runs[otherRun] = {max: 0};
      runs[otherRun][bm.id] = {time: this.otherruns[i][otherRun]};
      runs[otherRun].max = Math.max(runs[otherRun].max,
				    this.otherruns[i][otherRun]);
    }
  }
  return runs;
};

/**
 * Displays the most up to date benchmark numbers.
 */
BenchmarkSuite.prototype.displayGraph = function() {
  var runs = this.parseRuns();
  var displayOtherRuns = this.report_runs;
  var normalize = this.normalize;

  // Find the maximum number of other runs for any benchmark.
  var numRuns = 0;
  for (var run in runs) {
    if (normalize) {
      for (var bm in runs[run]) {
	runs[run][bm].time = 100.0 * runs[run][bm].time / runs[run].max;
      }
    }
    ++numRuns;
  }
  if (!displayOtherRuns) {
    numRuns = 1;
  }

  // Initialize the graph element.
  var height = this.benchmarks.length ?
               (this.benchmarks.length * (numRuns) * 13 + 
		(1 + this.benchmarks.length) * 5) + 32 : 0;
  var width = 600;
  var graphElem = getOrAddElement('graphElem', 'IMG',
				  {'width': width, 'height': height});
  if (!height) {
    graphElem.height = 0;
    return;
  }

  var maxTime = 0;
  var chd = [];
  var chm = [];
  var chxl = [];
  var chdl = [];
  var chco = [];
  var bm;
  var timeSeries;
  var datasetIndex = 0;

  for (var run in runs) {
    timeSeries = [];
    for (var i = 0, len = this.benchmarks.length; i < len; ++i) {
      bm = this.benchmarks[i];
      if ('Current runs' == run) {
        chxl.push(bm.name);
      } else if (!displayOtherRuns) {
        continue;
      }
      if (runs[run][bm.id]) {
        var iters = runs[run][bm.id].runs;
        var time = runs[run][bm.id].time;
        timeSeries.push(time ? time.toFixed(6) : '0');
	maxTime = Math.max(maxTime, time);

	var label;
	if (normalize) {
	  label = Math.round(time) == 100 ? formatTime(runs[run].max) :
	      formatPercent(time);
	} else {
	  label = formatTime(time);
	}
	chm.push(['t', label, iters ? (' (' + iters + '+runs)') : '',
		  ',000000,', datasetIndex, ',', i, ',13,1'].join(''));
      } else {
        timeSeries.push(0);
      }
    }
    if (timeSeries.length) {
      chdl.push(run);
      chco.push(getColor(run));
      chd.push(timeSeries.join(','));
    }
    ++datasetIndex;
  }

  var dataScale;
  var xAxisLabel;
  var xAxisGradiation;
  if (normalize) {
    dataScale = '';
    xAxisLabel = 'Percent of slowest benchmark for browser';
    xAxisGradiation = 100;
  } else {
    dataScale = '&chds=0,' + maxTime;
    xAxisLabel = (maxTime >= 1 ? 'ms' : 'us') + '+per+iteration';
    xAxisGradiation = maxTime >= 1 ? maxTime : maxTime * 1000;
  }

  // Build the final GET request for the chart.
  graphElem.src = ['http://chart.apis.google.com/chart?cht=bhg&chs=', width,
		   'x', height, '&chd=t:', chd.join('|'), '&chm=',
		   chm.join('|'), '&chbh=12,1,5&chco=', chco.join(','),
		   dataScale, '&chxt=x,x,y&chxr=0,0,', xAxisGradiation,
		   '&chxl=1:|', xAxisLabel, '|2:|', chxl.reverse().join('|'),
		   '&chdl=', chdl.join('|')].join('');
  graphElem.height = height;
};

/**
 * A container class for a benchmark.
 * @param {Object|string} f A Function or string to use for this benchmark.
 * @param {boolean} skipSave If specified, this benchmark is not saved.
 * @constructor
 */
function Benchmark(f) {
  this.runs = 0;
  this.time = 0;

  // If f is a string, then evaluate it first.
  if ('string' == typeof(f)) {
    var match = /function\s+(\w+)/.exec(f);
    if (match) {
      // Be careful about how the updated script is created so that it
      // performs identically to the original.
      if (window.execScript) {  // IE
        window.execScript(f);
        window.tempfn = window[match[1]];
      } else {  // OTHERS
	var newScript = document.createElement('script');
	newScript.innerHTML = 'window.tempfn = ' + f;
        document.getElementsByTagName("head")[0].appendChild(newScript);
      }
    }
    this.body = f;
    this.fn = window.tempfn;
  } else {
    this.body = ''+f;
    this.fn = f;
  }

  // Set the function's name.
  if (this.fn.name) {
    this.name = this.fn.name;  // Firefox shortcut.
  } else {
    // Source is in the format 'function name() {....}'.
    var source = '' + this.fn;
    this.name = source.substring(9, source.indexOf('('));
  }

  // Use a djb hash of the function body as the id.
  this.id = djbHash(this.body);
}

/**
 * Measures the execution time of n iterations of the given function.
 * @param {number} n The number of times to instrument the function.
 * @param {Array} p The parameters to pass to the function.
 * @param {number} c The control time to subtract for loop overhead.
 * @return {number} The total time for all iterations.
 */
Benchmark.prototype.run = function(n, p, c) {
  var i = n;
  var f = this.fn;
  var startTime, endTime;

  try {
    startTime = new Date().getTime(); 
    for (; i > 0; --i) {
      f.apply(window, p);
    }
    endTime = new Date().getTime();
  } catch (e) {
    alert(this.name + ' threw error: ' + e);
    return -1;
  }

  var newTime = endTime - startTime - c;
  if (newTime < 0) newTime = 0;
  if (newTime > 9000) {
    alert(this.name + ' took too long, please decrease iterations.')
    return -1;
  }

  this.time = ((this.runs * this.time) + newTime) / (n + this.runs)
  this.runs += n;

  return newTime;
};

/**
 * Queries the service for all suites that belong to this user and
 * asynchonously calls displaySuites when it returns.
 */
function listSuites() {
  post('/listsuites', "",
       (function(response) {
	  if (response) {
	    var suites = window.eval('(' + response + ')');

	    var msg = ['Running on: ', suites.useragent];
	    if (!window.isSignedIn) {
              msg[msg.length] = '<br><font color=red>Sign in <b>first</b> to save suites</font>';
	    }
	    // The first suite that comes back is actually the useragent.
	    getOrAddElement('spanRunningOn', 'SPAN', {}).innerHTML =
		msg.join('');
	    delete suites.useragent;
	    displaySuites(suites);
          }
	}));
}

/**
 * Updates the dropdown box with a list of all Suite options.
 */
function displaySuites(suites) {
  var options = document.forms[0].selSuites.options;
  var addedSuite = false;

  options[0] = new Option(' ', '');
  for (var suite in suites) {
    options[options.length] = new Option(suites[suite], suite);
    addedSuite = true;
  }
  if (addedSuite) {
    options[options.length] = new Option('-------------', '-');
  }
  options[options.length] = new Option('Load from URL', 'load');
  options[options.length] = new Option('New Suite', 'new');

  loadSuite(options[0].value);
}

/**
 * Adds the current suite to the list if it is not already there.
 */
function addCurrentSuiteToList() {
  var options = document.forms[0].selSuites.options;
  var isInList = false;
  for (var i = options.length - 1; i >= 0; --i) {
    if (options[i].value == window.benchmarksuite.id) {
      isInList = true;
    }
  }
  if (!isInList) {
      document.forms[0].selSuites.insertBefore(
	  new Option(window.benchmarksuite.name, window.benchmarksuite.id),
	  document.forms[0].selSuites.options[1]);
  }
}

/**
 * Parses and handles the option chosen from the suites dropdown box.
 * @param {string} suite The value of the selected option.
 */
function loadSuite(suite) {
  if (!suite || '-' == suite) {
    return;
  } else if (suite.match(/\.js$/)) {
    loadSuiteFromSource(suite);
  } else if ('load' == suite) {
    var src = prompt('Enter the URL of suite:');
    if (src) {
      loadSuiteFromSource(src);
    }
  } else if ('new' == suite) {
    var name = window.isSignedIn ?
	prompt('Enter a name for the suite:') : 'Unnamed suite';
    if (!name) return;
    var suite = new BenchmarkSuite(name, 10000, [], true);
    suite.benchmarks[suite.benchmarks.length] = 
	new Benchmark('function empty() {}');
    suite.display();
    suite.saveSuite();
  } else {
    // Lookup suite and load it.
    var params = ['id=', suite].join('');
    post('/getsuite', params, 
	 (function(response) {
	   if (response) {
             var s = window.eval('(' + response + ')');
	     new BenchmarkSuite(s.name, s.iterations, s.params, true,
				s.benchmarks);
	     window.benchmarksuite.id = suite;
	     window.location.hash = 'suite=' + suite;
	   }
	 }));
  }
}

/**
 * Loads a suite for a .js file.
 * @param {string} src The path to the suite's source file.
 */
function loadSuiteFromSource(src) {
  var newScript = document.createElement('script');
  newScript.type = 'text/javascript';
  newScript.src = src;
  document.getElementsByTagName("head")[0].appendChild(newScript);
}

/**
 * Returns the color to use for a given parsed userAgent.
 * @param {string} userAgent The parsed user agent as returned by the service.
 * @return {string} A 6 digit hex color.
 */
function getColor(userAgent) {
  if (userAgent.length >= 2) {
    var browser = userAgent.substring(0, 2);
    switch (browser) {
    case 'OP':
      return 'b5181a';
    case 'SF':
      return 'abaaa9';
    case 'IE':
      return '2a92e0';
    case 'NS':
      return '0f7c82';
    case 'FF':
      return 'e69129';
    case 'Cu':
      return '0095b6';
    }
  }
  return '999999';
}

/**
 * Returns a djb hash of the input string (ignoring spaces).
 */
function djbHash(input) {
  var hash = 5381;
  var stripped_input = input.replace(/\s/g, '');  // Ignore spaces, lf, etc.
  for (var i = stripped_input.length - 1; i >= 0; --i) {
    hash = 33 * hash ^ stripped_input.charCodeAt(i);
  }
  return hash;
}

/**
 * Formats a number as a percentage w/ 2 decimal places.
 */
function formatPercent(percent) {
  return (Math.round(100 * percent|0) / 100) + '%';
} 

/**
 * Formats a time in ms for display to the user.
 */
function formatTime(time_ms) {
  var us = 1000 * time_ms;
  if (us >= 1000) {
    return (Math.round(us) / 1000) + 'ms';
  }
  return (Math.round(1000 * us) / 1000) + 'us';
}

/**
 * Retrieves an element if it exists or adds it to the end of the body.
 *
 * @param {string} id The id of the element.
 * @param {string} type The type of the element.
 * @param {Object} properties A map of properties to add to the element if
 *     it was created.
 * @param {Object} parent The parent element to add this to.
 * @param {Object} insertAfter The element to insert befre.
 */
function getOrAddElement(id, type, properties, parent, insertBefore) {
  var elem = document.getElementById(id);
  if (!elem) {
    elem = document.createElement(type);
    elem.id = id;
    for (var p in properties) {
      elem[p] = properties[p];
    }
    if (!parent) parent = document.getElementById('contentDiv');
    if (insertBefore) {
      parent.insertBefore(elem, insertBefore);
    } else {
      parent.appendChild(elem);
    }
  }
  return elem;
}

/**
 * Logs the given message and label to the page.
 * @param {string} label A bold label to display before the message.
 * @param {string} msg The message to log.
 */
function log(label, msg) {
  var logElem = getOrAddElement('logElem', 'DIV');
  if (label) label = (''+label).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/\n/g, '<br>');;
  if (msg) msg = (''+msg).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/\n/g, '<br>');
  var logLine = ['<br><b>', label, '</b>: ', msg].join('');
  logElem.innerHTML += logLine;
}

/**
 * Returns and XMLHttpRequest object that works for all browsers.
 */
function createXMLHttpRequest() {
  try { return new XMLHttpRequest(); } catch(e) {}
  try { return new ActiveXObject("Msxml2.XMLHTTP"); } catch (e) {}
  try { return new ActiveXObject("Microsoft.XMLHTTP"); } catch (e) {}
  return null;
}

/**
 * Posts the given params to the given path and calls the responseHandler.
 */
function post(path, params, responseHandler) {
  var self = this;
  var xhr = createXMLHttpRequest();
  if (!xhr) return;
  xhr.open('POST', path, true);
  xhr.setRequestHeader('Content-type', 'application/x-www-form-urlencoded');
  xhr.setRequestHeader('Content-length', params.length);
  xhr.setRequestHeader('Connection', 'close');
  if (responseHandler) {
    xhr.onreadystatechange = function() {
      if (xhr.readyState != 4 || xhr.status != 200) return;
      responseHandler(xhr.responseText);
    };
  }
  xhr.send(params);
}